package de.akabahn.ebd.dbd.impl;

import static de.akabahn.ebd.comm.Config.DBD_PORT_BINARY;
import static de.akabahn.ebd.comm.Config.DBD_PORT_TEXT;
import static de.akabahn.ebd.comm.Config.DBD_SERVER;
import static de.akabahn.ebd.comm.Util.assertNotNull;
import static de.akabahn.ebd.comm.Util.assertNotNullAndEmpty;
import static de.akabahn.ebd.comm.Util.waitForLatch;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Observable;
import java.util.Observer;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.net.telnet.TelnetClient;

import de.akabahn.ebd.comm.dbd.DBDCommand;
import de.akabahn.ebd.comm.dbd.DBDConnectionClient;
import de.akabahn.ebd.comm.dbd.DBDInputListener;
import de.akabahn.ebd.comm.dbd.DBDParameter;
import de.akabahn.ebd.dbd.DBDAdapter;
import de.akabahn.ebd.dbd.DBDAdapterFactory;
import de.akabahn.ebd.dbd.DBDException;
import de.akabahn.ebd.dbd.DBDSignal;
import de.akabahn.ebd.dbd.DBDSubscriptionListener;
import de.akabahn.ebd.dbd.SwitchPosition;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * Class that manages the DBD client application (connecting to the DBD server,
 * sending commands to the server, receiving and parsing the responses from the
 * server).<br>
 * This class manages its life cycle state, that means it has to be started and
 * stopped in order to use it.<br>
 * Instances of this class should be created by using the
 * <code>DBDAdapterFactory</code> class.<br>
 * <br>
 * Currently supported commands are:<br>
 * <ul>
 * <li><code>Reload DBD variables</code></li>
 * <li><code>List available values</code>
 * <li><code>List available switches</code></li>
 * <li><code>Query variables</code></li>
 * <li><code>Set variables</code></li>
 * <li><code>Subscribe to variables</code></li>
 * <li><code>Unsubscribe from variables</code></li>
 * <li><code>Force variables</code></li>
 * <li><code>Unforce variables</code></li>
 * </ul>
 * 
 * @author Peter Eimann
 * @since 0.2
 * @version 1.0
 * @see DBDAdapterFactory
 */
public class DBDAdapterImpl implements DBDAdapter, DBDInputListener, Observer {

	/**
	 * Pattern to find all DBD variables containing the current status of a
	 * switch (e.g. "13W2I")
	 */
	private static final Pattern SWITCH = Pattern.compile("^(\\d+W\\d+)I\\s[12]$");

	private boolean connected;
	private boolean started;
	private DBDConnectionClient dbdConnection;
	private ExecutorService executor;

	private Map<String, List<DBDSubscriptionListener>> subscriptions;
	private Map<String, DBDInputListener> listener;

	private String[] dbdVariables;

	/**
	 * Initialize the instance by creating a new
	 * <code>DBDConnectionClient</code> instance and adding the observer objects
	 * to it. The instances also registers itself as a
	 * <code>DBDInputListener</code> to be able to receive the responses from
	 * the DBD server.<br>
	 * The connection is made to the server defined in the
	 * <code>DBD_SERVER</code> constant (192.168.2.5) and to the port defined in
	 * the <code>DBD_PORT_BINARY</code> constant (1436).
	 * 
	 * @param connectionObservers
	 *            The observer classes that want to get notified when the
	 *            connection status to the DBD server changes
	 * @see #DBDAdapterImpl(String, int, Observer...)
	 */
	public DBDAdapterImpl(Observer... connectionObservers) {
		this(DBD_SERVER, DBD_PORT_BINARY, connectionObservers);
	}

	/**
	 * Initialize the instance by creating a new
	 * <code>DBDConnectionClient</code> instance and adding the observer objects
	 * to it. The instances also registers itself as a
	 * <code>DBDInputListener</code> to be able to receive the responses from
	 * the DBD server.
	 * 
	 * @param server
	 *            The server to connect to
	 * @param port
	 *            The port to connect to
	 * @param connectionObservers
	 *            The observer classes that want to get notified when the
	 *            connection status to the DBD server changes
	 */
	public DBDAdapterImpl(String server, int port, Observer... connectionObservers) {
		dbdConnection = new DBDConnectionClient(server, port);
		dbdConnection.registerInputListener(this);
		dbdConnection.addObserver(this);

		if (connectionObservers != null) {
			for (Observer connectionObserver : connectionObservers) {
				dbdConnection.addObserver(connectionObserver);
			}
		}

		executor = Executors.newCachedThreadPool();

		subscriptions = new HashMap<String, List<DBDSubscriptionListener>>();
		listener = new HashMap<String, DBDInputListener>();
	}

	/**
	 * Establish the connection to the DBD server and set the life cycle status
	 * of this instance to <code>started</code>. If it's already started,
	 * nothing happens.
	 * 
	 * @throws DBDException
	 */
	@Override
	public synchronized void start() throws DBDException {
		if (!started) {
			try {
				dbdConnection.connect();
				started = true;
			} catch (RuntimeException re) {
				throw new DBDException("Start failed", re);
			}
		}
	}

	/**
	 * Disconnect from the DBD server and set the life cycle status of this
	 * instance to <code>stopped</code>. If it's already stopped, nothing
	 * happens.
	 * 
	 * @throws DBDException
	 */
	@Override
	public synchronized void stop() throws DBDException {
		if (started) {
			try {
				dbdConnection.disconnect();
			} catch (RuntimeException re) {
				throw new DBDException("Stop failed", re);
			}
			started = false;
		}
	}

	/**
	 * Return if the current life cycle status is <code>started</code> or not
	 * 
	 * @return <code>true</code> if the life cycle status is
	 *         <code>started</code>
	 */
	@Override
	public boolean isStarted() {
		return started;
	}

        protected void setDBDVariables(String[] dbdVariables) {
            this.dbdVariables = dbdVariables;
        }

	/**
	 * Reload the list of all known variables from the DBD server. Needs to be
	 * called if the cached list of DBD variables needs to be updated.
	 * 
	 * @see #loadDBDVariables()
	 */
	@Override
	public void reloadDBDVariables() {
		loadDBDVariables();
	}

	/**
	 * List all variables that are currently known in the DBD server for
	 * requested (or all) stations.<br>
	 * If the dbd variables haven't been loaded before,
	 * <code>loadDBDVariables()</code> is called first. This list is then cached
	 * and reused for subsequent calls to this method. To reload the variables
	 * (e.g. because something has changed), you have to call
	 * <code>reloadDBDVariables()</code>.<br>
	 * 
	 * @param stationNumbers
	 *            A list of station numbers for which the variables will be
	 *            returned (if <code>null</code> is passed, all variables are
	 *            returned)
	 * @return The list of variables for the passed station numbers
	 * @throws DBDException
	 * @see #loadDBDVariables()
	 * @see #matchesStation(String, String[])
	 */
	@Override
	public List<String> listAvailableVariables(String[] stationNumbers) throws DBDException {

		if (dbdVariables == null) {
			loadDBDVariables();
		}

		List<String> valueList = new ArrayList<String>();

		for (String dbdVariable : dbdVariables) {
			if (matchesStation(dbdVariable, stationNumbers)) {
				valueList.add(dbdVariable.split(" ")[0]);
			}
		}

		Collections.sort(valueList, new Comparator<String>() {
			public int compare(String lhs, String rhs) {
				return lhs.compareTo(rhs);
			}
		});

		return valueList;
	}

	/**
	 * List all switches that are currently known in the DBD server for
	 * requested (or all) stations.<br>
	 * If the dbd variables haven't been loaded before,
	 * <code>loadDBDVariables()</code> is called first. This list is then cached
	 * and reused for subsequent calls to this method. To reload the variables
	 * (e.g. because something has changed), you have to call
	 * <code>reloadDBDVariables()</code>.<br>
	 * The switch variables are extracted from the list of DBD variables using
	 * the <code>SWITCH</code> pattern.<br>
	 * 
	 * @param stationNumbers
	 *            A list of station numbers for which the switches will be
	 *            returned (if <code>null</code> is passed, all switches are
	 *            returned)
	 * @return The list of switches for the passed station numbers
	 * @throws DBDException
	 * @see #loadDBDVariables()
	 * @see #matchesStation(String, String[])
	 */
	@Override
	public List<String> listAvailableSwitches(String[] stationNumbers)
			throws DBDException {

		if (dbdVariables == null) {
			loadDBDVariables();
		}

		List<String> switchList = new ArrayList<String>();

		for (String dbdVariable : dbdVariables) {
			Matcher m = SWITCH.matcher(dbdVariable);

			if (m.matches() && matchesStation(dbdVariable, stationNumbers)) {
				switchList.add(m.group(1));
			}
		}

		Collections.sort(switchList, new Comparator<String>() {
			public int compare(String lhs, String rhs) {
				return lhs.compareTo(rhs);
			}
		});

		return switchList;
	}

	/**
	 * Query the current value of the DBD variable with the passed
	 * <code>name</code>.
	 * 
	 * @param name
	 *            The name of the variable to query
	 * @return The current value of the variable wrapped in a
	 *         <code>DBDParameter</code>
	 * @throws DBDException
	 * @see #query(String, Class)
	 */
	@Override
	public DBDParameter query(String name) throws DBDException {
		return query(name, DBDParameter.class);
	}

	/**
	 * Query the current value of the DBD variable with the passed
	 * <code>name</code> and return the response as the passed <code>type</code>
	 * .<br>
	 * Currently supported types are:
	 * <ul>
	 * <li><code>DBDParameter.class</code>: Returns the value of the variable as
	 * a dbd parameter object</li>
	 * <li><code>Integer.class</code>: Returns the value of the variable as an
	 * integer value</li>
	 * <li><code>SwitchPosition.class</code>: Returns the value of the variable
	 * as a switch position representation (the name of the variable should
	 * represent a switch in this case)</li>
	 * <li><code>DBDSignalD.class</code>: Returns the value of the variable as a
	 * German signal representation (the name of the variable should represent a
	 * German signal in this case)</li>
	 * <li><code>DBDSignalF.class</code>: Returns the value of the variable as a
	 * French signal representation (the name of the variable should represent a
	 * French signal in this case)</li>
	 * </ul>
	 * 
	 * @param name
	 *            The name of the variable to query
	 * @param type
	 *            The type of the response object
	 * @return The current value of the variable represented by an object of the
	 *         passed type (maybe <code>null</code> if no response has been
	 *         received)
	 * @throws DBDException
	 * @throws IllegalArgumentException
	 *             if an unsupported type is passed
	 * @see #writeAndRead(DBDParameter)
	 */
	@Override
	public <T> T query(String name, Class<T> type) throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");
		assertNotNull(type, "type");

		DBDParameter res = writeAndRead(new DBDParameter(DBDCommand.QUERY, name));

		if (res != null) {
			if (type == Integer.class) {
				return type.cast(Integer.valueOf(res.getValue()));
			} else if (type == SwitchPosition.class) {
				return type.cast(SwitchPosition.fromValue(res.getValue()));
			} else if (type == DBDSignalD.class) {
				return type.cast(new DBDSignalD(res.getValue()));
			} else if (type == DBDSignalF.class) {
				return type.cast(new DBDSignalF(res.getValue()));
			} else if (type == DBDParameter.class) {
				return type.cast(res);
			} else {
				throw new IllegalArgumentException("Unsupported class type " + type.getName());
			}
		} else {
			return null;
		}
	}

	/**
	 * Set the DBD variable <code>name</code> to the passed <code>value</code>.
	 * 
	 * @param name
	 *            The name of the variable to set
	 * @param value
	 *            The new value of the variable
	 * @throws DBDException
	 */
	@Override
	public void set(String name, int value) throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

		write(new DBDParameter(DBDCommand.SET, name, value));
	}

	/**
	 * Set the DBD variable <code>name</code>, which refers to a switch, to the
	 * passed position.
	 * 
	 * @param name
	 *            The name of the variable (switch) to set
	 * @param pos
	 *            The new position of the switch
	 * @throws DBDException
	 * @see #write(DBDParameter)
	 */
	@Override
	public void set(String name, SwitchPosition pos) throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

		write(new DBDParameter(DBDCommand.SET, name, pos.getPosition()));
	}

	/**
	 * Set the DBD variable <code>name</code>, which refers to a signal, to the
	 * passed signal aspect.
	 * 
	 * @param name
	 *            The name of the variable (signal) to set
	 * @param signal
	 *            The new signal aspect to show
	 * @throws DBDException
	 * @see #write(DBDParameter)
	 */
	@Override
	public void set(String name, DBDSignal signal) throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

		write(new DBDParameter(DBDCommand.SET, name, signal.getIntValue()));
	}

	/**
	 * Force the variable <code>name</code> to the passed <code>value</code>.
	 * 
	 * @param name
	 *            The name of the variable
	 * @param value
	 *            The value to force the variable to (write protection)
	 * @throws DBDException
	 * @see #write(DBDParameter)
	 */
	@Override
	public void force(String name, int value) throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

		write(new DBDParameter(DBDCommand.FORCE, name, value));
	}

	/**
	 * Unforce the variable <code>name</code> and remove the write protection.
	 * 
	 * @param name
	 *            The name of the variable
	 * @throws DBDException
	 * @see #write(DBDParameter)
	 */
	@Override
	public void unforce(String name) throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

		write(new DBDParameter(DBDCommand.UNFORCE, name));
	}

	/**
	 * Subscribe to the variable with the passed <code>name</code> and pass the
	 * <code>DBDSubscriptionListener</code> that will be notified when this
	 * variable has changed.<br>
	 * To know the current value of the variable, a query command is sent to the
	 * DBD server after the subscription.
	 * 
	 * @param name
	 * 
	 *            The name of the variable to subscribe to
	 * @param listener
	 *            The subscription listener that is called (via the
	 *            <code>onChange()</code> method) when the variable has changed
	 * @throws DBDException
	 * @throws IllegalArgumentException
	 *             thrown if the input parameters are invalid
	 */
	@Override
	public void subscribe(String name, DBDSubscriptionListener listener)
			throws DBDException {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");
		assertNotNull(listener, "listener");

        List<DBDSubscriptionListener> subscribers = subscriptions.get(name.toUpperCase());
        if (subscribers == null) {
            subscribers = new CopyOnWriteArrayList<DBDSubscriptionListener>();
            subscriptions.put(name.toUpperCase(), subscribers);
        }
        subscribers.add(listener);

		write(new DBDParameter(DBDCommand.SUBSCRIBE, name));
		write(new DBDParameter(DBDCommand.QUERY, name));
	}

	/**
	 * Unsubscribe from the variable with the passed <code>name</code>.
	 *
	 * @param name
	 *            The name of the variable to unsubscribe from
	 * @throws IllegalArgumentException
	 *             thrown if the input parameters are invalid
	 */
	@Override
	public void unsubscribe(String name) {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

		subscriptions.remove(name.toUpperCase());
	}

	/**
	 * Unsubscribe a specific <code>listener</code> from the variable with the
	 * passed <code>name</code>.
	 *
	 * @param name
	 *            The name of the variable to unsubscribe from
	 * @param listener
	 *            The subscription listener to be removed
	 */
	@Override
	public void unsubscribe(String name, DBDSubscriptionListener listener) {
		assertIsStarted();
		assertNotNullAndEmpty(name, "name");

        List<DBDSubscriptionListener> subscribers = subscriptions.get(name.toUpperCase());
        if (subscribers != null) {
        	if (subscribers.size() == 1) {
        		subscriptions.remove(name.toUpperCase());
        	} else {
        		subscribers.remove(listener);
            }
        }
	}

	/**
	 * Called by the <code>DBDConnectionClient</code> when data has been
	 * received from the DBD server.<br>
	 * If there's a subscription for the received variable,
	 * <code>onChange()</code> is called to notify the subscriber of the change.<br>
	 * If there's a listener for the received variable,
	 * <code>onInputAvailable()</code> is called to notify the listener of the
	 * response.
	 * 
	 * @param dbd
	 *            The response from the DBD server
	 * @see DBDSubscriptionListener#onChange(DBDParameter)
	 * @see DBDInputListener#onInputAvailable(DBDParameter)
	 */
	public void onInputAvailable(DBDParameter dbd) {
		if (subscriptions.get(dbd.getName()) != null) {
			for (DBDSubscriptionListener subscriber : subscriptions.get(dbd.getName())) {
				subscriber.onChange(dbd);
			}
		}
		if (listener.get(dbd.getName()) != null) {
			listener.get(dbd.getName()).onInputAvailable(dbd);
		}
	}

	/**
	 * Called by the <code>DBDConnectionClient</code> when the connection status
	 * of the DBD server has changed.<br>
	 * If the status change was from &quot;disconnected&quot; to
	 * &quot;connected&quot;, all registered subscriptions are send to the DBD
	 * again.
	 * 
	 * @see java.util.Observer#update(java.util.Observable, java.lang.Object)
	 */
	public void update(Observable observable, Object data) {
		boolean nowConnected = ((Boolean) data).booleanValue();

		if (!connected && nowConnected) {
			for (String name : subscriptions.keySet()) {
				for (DBDSubscriptionListener subscriber: subscriptions.get(name)) {
					subscribe(name, subscriber);
				}
			}
		}

		connected = nowConnected;
	}

	/**
	 * Assert that the current life cycle status is <code>started</code> and if
	 * not, an <code>DBDException</code> is thrown.
	 * 
	 * @throws DBDException
	 *             Thrown if the current life cycle is not <code>started</code>
	 */
	void assertIsStarted() {
		if (!isStarted()) {
			throw new DBDException("DBDAdapter is not started, call start() first");
		}
	}

	/**
	 * Set the <code>started</code> flag.
	 *
	 * @param isStarted
	 *            The new value
	 */
	void setStarted(boolean isStarted) {
		this.started = isStarted;
	}

	/**
	 * Write the passed <code>DBDParameter</code> to the DBD server using the
	 * binary protocol.
	 * 
	 * @param dbd
	 *            The parameter to send
	 * @throws DBDException
	 */
	void write(DBDParameter dbd) throws DBDException {
		try {
			dbdConnection.write(dbd);
		} catch (RuntimeException re) {
			throw new DBDException("Communication failed", re);
		}
	}

	/**
	 * Write the passed <code>DBDParameter</code> to the DBD server using the
	 * binary protocol and wait for the response by temporarily registering an
	 * input listener for the variable. This listener will be called, when the
	 * response has been received from the DBD server. After that, the input
	 * listener will be removed.
	 * 
	 * @param request
	 *            The parameter to send
	 * @return The response from the DBD server (may be <code>null</code> if no
	 *         response has been received within
	 *         <code>Config.DBD_READ_TIMEOUT</code> ms.
	 * @throws DBDException
	 */
	DBDParameter writeAndRead(DBDParameter request) throws DBDException {
		final DBDParameter response = new DBDParameter();
		final CountDownLatch latch = new CountDownLatch(1);

		listener.put(request.getName(), new DBDInputListener() {
			public void onInputAvailable(DBDParameter dbd) {
				response.setCommand(dbd.getCommand());
				response.setName(dbd.getName());
				response.setValue(dbd.getValue());

				latch.countDown();
			}
		});

		write(request);

		try {
			if (!waitForLatch(latch, 5000)) {
				return null;
			} else {
				return response;
			}
		} finally {
			listener.remove(request.getName());
		}
	}

	/**
	 * Load all known variables from the DBD server by sending the command
	 * <code>*</code> to the server, using the text protocol. After the response
	 * has been received, the connection to the server is closed by sending the
	 * command <code>$</code>. As the server doesn't send an explicit end marker
	 * after the last variable, reading the response is repeated until the queue
	 * that contains the received response doesn't contain any more elements.
	 * 
	 * @throws DBDException
	 */
	void loadDBDVariables() throws DBDException {
		final LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
		final TelnetClient client = new TelnetClient();
		final ExceptionWrapper exceptionWrapper = new ExceptionWrapper();
		StringBuilder builder = new StringBuilder();
		Integer out = null;

		executor.execute(new Runnable() {
			public void run() {
				int in = -1;
				try {
					client.connect(DBD_SERVER, DBD_PORT_TEXT);
					client.getOutputStream().write("*\r\n".getBytes());
					client.getOutputStream().flush();

					while ((in = client.getInputStream().read()) != -1) {
						queue.add(Integer.valueOf(in));
					}
				} catch (IOException e) {
					exceptionWrapper.wrappedException = e;
				}
			}
		});

		try {
			do {
				try {
					out = queue.poll(2, TimeUnit.SECONDS);
					if (out != null) {
						builder.append((char) out.intValue());
					}
				} catch (InterruptedException e) {
					out = Integer.MIN_VALUE;
				}
			} while (out != null);

			if (exceptionWrapper.wrappedException != null) {
				throw new DBDException("Read all variables failed", exceptionWrapper.wrappedException);
			}
		} finally {
			if (client != null) {
				try {
					client.getOutputStream().write("$\r\n".getBytes());
					client.getOutputStream().flush();
				} catch (IOException e) {
				}
				try {
					client.disconnect();
				} catch (IOException e) {
				}
				queue.clear();
			}
		}

                setDBDVariables(builder.toString().split("\\r\\n"));
	}

	/**
	 * Check if the passed value starts with one of the stationNumbers from the
	 * passed <code>stationNumbers</code> array.
	 * 
	 * @param value
	 *            The value to check
	 * @param stationNumbers
	 *            The valid station prefixes
	 * @return <code>true</code>, if either the value starts with one the passed
	 *         station numbers or if the passed array is null or of zero length,
	 *         <code>false</code> otherwise
	 */
	boolean matchesStation(String value, String[] stationNumbers) {
		if (stationNumbers == null || stationNumbers.length == 0) {
			return true;
		} else {
			for (String stationNumber : stationNumbers) {
				if (stationNumber == null) {
					continue;
				}
				if (value.startsWith(stationNumber)) {
					return true;
				}
			}
		}

		return false;
	}

	public static class ExceptionWrapper {
		public Exception wrappedException;
	}
}
